Skip to content

Fix packet flood caused by dead scale/color dirty-check - #76

Open
tommasov03 wants to merge 1 commit into
GeyserExtensionists:mainfrom
ScarletMC:fix/packet-flood
Open

Fix packet flood caused by dead scale/color dirty-check#76
tommasov03 wants to merge 1 commit into
GeyserExtensionists:mainfrom
ScarletMC:fix/packet-flood

Conversation

@tommasov03

Copy link
Copy Markdown

Problem

sendScale() and sendColor() compare the current value against the last one
sent, but never store the new value. setLastScale() and setLastColor() have
no call sites anywhere in the repository — only their four declarations. So
lastScale stays at its initial -1.0f and lastColor stays null forever,
every comparison fails, and the 20 ms task re-sends identical data indefinitely.

Measured on a production Paper + Geyser/Floodgate + ModelEngine server, 52
models visible to 2 Bedrock players, over a 12.2 s capture of geyserutils:main:

Plugin messages 95,569 (~7,800/s)
Unique payloads 296 — 99.7% exact duplicates
Payload volume 15.6 MB (~1.28 MB/s)
Per entity, per viewer ~100 pkt/s = 50 Hz × 2 packets

Traffic composition: scale=1.0 47,511 (49.7%), color=-1 45,192 (47.3%).

Control within the same capture: animation properties go through
lastIntSet, a cache that is written. They produced 342 packets in the same
12 seconds, against 92,703 for scale/color. Same plugin, same window, 271×
difference — this isolates the cause to the dead dirty-check rather than to
network conditions, Geyser, or entity count.

Cost scales linearly in both entities and Bedrock viewers, and there is no
config workaround: the 20 ms period is hardcoded.

Changes

The flood

ModelEnginePropertyHandler — store the value after sending. getEntityTask()
on ModelEngineEntityData is already covariant, so no cast is needed.

BetterModelPropertyHandler — three separate issues on this path:

  • sendScale() had no dirty-check at all (the lastScale parameter was unused)
    and no players.isEmpty() guard.
  • sendColor() had the condition inverted: if (firstSend) applied the check
    only on the first send — where it should be forced — and never on the periodic
    loop. It now matches the ModelEngine path.
  • setHurt(false) moved to where the tint is read, from after the send loop.
    This is required, not cosmetic: with the dirty-check active the early return
    would otherwise never consume the flag and the model would stay tinted forever.
    Consuming it at read time gives one tint packet and one clear packet per hit.

Periodic forced resync in both task handlers:

boolean forceSync = tick % 100 == 0;   // ~2 s

Without this the fix would remove an unintended safety net. The property
handshake runs exactly once per viewer: sendEntityData() is only reached from
sendSpawnPacket(), i.e. on viewer add, and CustomEntitySpawnSynchronizer
schedules the scale/color callback a single time after the spawn resend window.
On the Geyser side, CustomEntityDataPacket is dropped silently when
getEntityByJavaId() returns null (entity not registered yet) — no retry, no
log. Today the 50 Hz loop masks any such loss within 20 ms; without a
replacement there would be no recovery path and an affected model would render
at default scale until the viewer leaves and re-enters range. A 2 s forced
resync keeps that self-healing property at ~104 pkt/s on the capture above,
still a 75× reduction. Happy to make the interval configurable if preferred.

Async correctness and scheduling

Smaller and independent of the above, found while tracing the same code paths:

  • BedrockMountControlRunnable was scheduled every 1 ms — 1000 executions
    per second on the same 4-thread pool that serves every per-entity task. It
    reads mount input from head pitch, which the client updates at 20 Hz. Now
    models.mount-control-period, default 50 ms.
  • NPE: Bukkit.getPlayer(uuid) returns null for a player who is
    disconnecting, since the cache entry is only removed on PlayerQuitEvent.
    Skip null players.
  • playerJoinedCache was an unsynchronized HashSet, written from the main
    thread on join/quit and iterated from the scheduler pool. Now
    ConcurrentHashMap.newKeySet(); the getter widens to Set<UUID>, which all
    four call sites already satisfy.
  • Bukkit.getOnlinePlayers() was called from an async thread once per model
    per tick
    — ~2,600 scans/s at 52 models. The Bedrock player list is now built
    once per global update cycle in UpdateTaskRunnable and read from a
    volatile field, leaving checkViewers()'s signature unchanged. Trade-off:
    new viewers are picked up with up to one global cycle (35 ms) of latency.
  • The 20 ms per-entity period is now models.entity-update-period, default
    20, so behaviour is unchanged for everyone but admins with many entities have
    a knob.

New config keys default to their current effective values, and ConfigManager
reads with an explicit default, so existing config.yml files keep working
untouched.

Expected result

On the captured workload: ~7,800 pkt/s → ~150 pkt/s — the ~104 pkt/s resync
floor, the 342 animation bundles (~28/s, already dirty-checked and unaffected by
this PR), and two packets per damage tick. Note that the 2,319 damage-tint
packets in the capture were themselves duplicates of a far smaller number of
actual hits, for the same reason as the rest of the flood.

Verification

The dead setters are visible in the source at
ModelEngineTaskHandler/BetterModelTaskHandler: lastScale and lastColor
are assigned in the constructor and in their own setters, and nowhere else.
grep -rn 'setLastScale\|setLastColor' --include='*.java' . returns the four
declarations and no call sites. The same holds on the released 1.0.9 jar:
putfield on either field appears only in <init> and in the setter, and there
is no invokevirtual to either setter.

The inverted condition in BetterModelPropertyHandler.sendColor() is visible
directly in the diff.

./gradlew :paper:compileJava passes.

sendScale() and sendColor() compared the current value against the last
sent one but never stored it, so lastScale/lastColor stayed at their
initial -1.0f/null forever and every comparison failed. With the 50Hz
per-entity task this resent scale and color to every viewer on every
tick: ~7.8k plugin messages/s on geyserutils:main for 52 models and 2
Bedrock players, of which 99.7% were byte-identical duplicates.

- Store the sent value in both ModelEngine and BetterModel handlers, so
  the existing dirty-check actually filters.
- BetterModel: sendScale had no dirty-check at all and sendColor had the
  condition inverted (it only applied on firstSend). Consume the hurt
  flag together with the tint calculation, otherwise an early return
  leaves the model tinted forever.
- Keep a forced resend every ~100 ticks. The one-shot send after spawn is
  dropped silently by the Geyser side when the custom entity is not
  registered yet, and the 50Hz loop was the only thing papering over it.
- Schedule BedrockMountControlRunnable at a configurable period
  (default 50ms) instead of 1ms, and the per-entity task at a
  configurable period (default 20ms, unchanged behaviour).
- Skip null players in BedrockMountControlRunnable: the quit handler
  removes the UUID after the player is already gone.
- Make playerJoinedCache a concurrent set; it was a plain HashSet written
  from the main thread and iterated from the scheduler pool.
- Cache the Bedrock player list once per global update cycle instead of
  scanning Bukkit.getOnlinePlayers() from every per-entity task.
Copilot AI lite review requested due to automatic review settings September 8, 2026 10:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The functional fixes and performance/scheduling changes are coherent and low-risk, with only minor naming/comment clarity nits noted.

Pull request overview

This PR fixes excessive custom entity property packet spam by repairing scale/color dirty-checking and adds a couple of scheduling/concurrency improvements to reduce async load in per-entity tasks.

Changes:

  • Fixes scale/color dirty-check behavior by persisting last-sent values and correcting logic/guards in the BetterModel path.
  • Adds a periodic forced resync (tick-based) to recover from the initial post-spawn send being dropped.
  • Introduces configurable scheduler periods, reduces mount-control frequency, hardens async player lookups, and caches Bedrock player lists once per global update cycle.
File summaries
File Description
paper/src/main/resources/config.yml Adds new config keys for entity update period and mount-control period.
paper/src/main/java/re/imc/geysermodelengine/runnables/UpdateTaskRunnable.java Refreshes cached Bedrock player list once per global update cycle.
paper/src/main/java/re/imc/geysermodelengine/runnables/BedrockMountControlRunnable.java Avoids NPE by skipping disconnecting players (null from Bukkit.getPlayer).
paper/src/main/java/re/imc/geysermodelengine/managers/model/taskshandler/ModelEngineTaskHandler.java Makes entity update period configurable; adds periodic forced resync for scale/color sends.
paper/src/main/java/re/imc/geysermodelengine/managers/model/taskshandler/BetterModelTaskHandler.java Makes entity update period configurable; adds periodic forced resync for scale/color sends.
paper/src/main/java/re/imc/geysermodelengine/managers/model/propertyhandler/ModelEnginePropertyHandler.java Writes last-sent scale/color back to the task after sending.
paper/src/main/java/re/imc/geysermodelengine/managers/model/propertyhandler/BetterModelPropertyHandler.java Adds missing dirty-check/guards for scale, fixes color dirty-check behavior, consumes hurt flag correctly, and writes last-sent values.
paper/src/main/java/re/imc/geysermodelengine/managers/model/ModelManager.java Makes playerJoinedCache thread-safe via a concurrent set and widens getter type.
paper/src/main/java/re/imc/geysermodelengine/managers/model/EntityTaskManager.java Caches Bedrock player list to avoid per-entity scans of Bukkit.getOnlinePlayers().
paper/src/main/java/re/imc/geysermodelengine/GeyserModelEngine.java Makes mount-control scheduler period configurable instead of fixed 1ms.
Review details

Suppressed comments (1)

paper/src/main/java/re/imc/geysermodelengine/managers/model/propertyhandler/BetterModelPropertyHandler.java:52

  • Same as sendScale: the firstSend parameter name no longer matches how the flag is being used (it now means "force-send/force-resync" as well). Renaming it to forceSend in this implementation will make the dirty-check logic easier to reason about.
    public void sendColor(EntityData entityData, Collection<Player> players, Color lastColor, boolean firstSend) {
        if (players.isEmpty()) return;

        BetterModelEntityData betterModelEntityData = (BetterModelEntityData) entityData;

  • Files reviewed: 10/10 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 32 to +40
public void sendScale(EntityData entityData, Collection<Player> players, float lastScale, boolean firstSend) {
if (players.isEmpty()) return;

BetterModelEntityData betterModelEntityData = (BetterModelEntityData) entityData;
Tracker tracker = (Tracker) betterModelEntityData.getModelInstance();
ModelScaler scaler = tracker.scaler();
var scale = scaler.scale(tracker);

if (!firstSend && scale == lastScale) return;
Comment on lines +88 to +91
// The first scale/color send after spawn is fired once, and the Geyser side silently drops it
// if the custom entity is not registered yet. Force a resend every ~2s so a model that missed
// that window recovers instead of staying at default scale / no tint forever.
boolean forceSync = tick % 100 == 0;
Comment on lines +92 to +95
// The first scale/color send after spawn is fired once, and the Geyser side silently drops it
// if the custom entity is not registered yet. Force a resend every ~2s so a model that missed
// that window recovers instead of staying at default scale / no tint forever.
boolean forceSync = tick % 100 == 0;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants